iT邦幫忙

2022 iThome 鐵人賽

DAY 15
0
Modern Web

你 React 了嗎? 30 天解鎖 React 技能系列 第 15

[DAY 15] React 表單處理,更新資料及儲存表單

  • 分享至 

  • xImage
  •  

[情境任務]

解師傅:我們的餐廳生意越來越好了,為了不讓客人排隊,我想客製一個點餐機~

小當家:啥?這是什麼玩意?

解師傅:直接在點餐機上選擇餐點跟輸入客人的資料,我們既不用自己點餐,客人也不用排隊,根本一舉兩得阿!

小當家:解師傅,你真是個天才!

現在我們已經有餐點了,還需要方便客人填寫資料的表單,一起動手做吧!


cover

表單處理

還記得在 DAY 2 時有提到,React 不做資料綁定,所以在資料有變更時,常常會用 onChange 去做資料的更新

由於一個表單可能會有多個欄位,所以這邊使用 object 來當預設值,方便之後擴充
為了不混淆,會將每個欄位拆開來看,以下分別為各種類型 inputselectradiocheckboxfile 的欄位運用


input 文字輸入

input 類型為 text

import { useState } from "react";

export default function App() {
  const [form, setForm] = useState({
    name: ""
  });

  const changeName = (e) => {
    setForm((state) => ({
      ...state,
      name: e.target.value
    }));
  };

  return (
    <form>
      <label htmlFor="name">姓名</label>
      <input
        id="name"
        type="text"
		name="name"
        value={form.name}
        onChange={changeName}
      />
    </form>
  );
}

input 會接收 valueonChange 事件,如 input 輸入的值變更,setForm 會將 form.name 變更為新的值,以達成 input 雙向綁定


input 類型為其他型別

import { useState } from "react";

export default function App() {
  const [form, setForm] = useState({
    number: ""
  });

  const changeNumber = (e) => {
    setForm((state) => ({
      ...state,
      number: parseInt(e.target.value, 10)
    }));
  };

  return (
    <form>
      <label htmlFor="num">此次用餐人數</label>
      <input
        id="num"
        type="number"
		name="number"
        value={form.number}
        onChange={changeNumber}
      />
    </form>
  );
}

「value 傳入的值一定會是字串」,所以如想要的值為其他型別,要記得轉型,上例的 input 為 Number 型態,需再使用 parseInt 將字串轉型為 number


Select 下拉選單

Select 綁定字串陣列

import { useState } from "react";

export default function App() {
  const age = [
    "18歲以下",
    "18歲~29歲",
    "30歲~39歲",
    "40歲~49歲",
    "50歲~59歲",
    "60歲以上"
  ];

  const [form, setForm] = useState({
    age: age[0]
  });

  const changeAge = (e) => {
    setForm((state) => ({
      ...state,
      age: e.target.value
    }));
  };

  return (
    <form>
      <label>請選擇您的年齡區間</label>

      <select name="age" value={form.age} onChange={changeAge}>
        {age.map((item) => (
          <option key={item.value} value={item}>{item}</option>
        ))}
      </select>

	  <h1>您選擇了: {form.age}</h1>
    </form>
  );
}

只有字串的陣列很單純,預設值設定第 0 筆,並用 map 渲染出列表,select 接收 valueonChange 事件,setForm 會將 form.age 變更為新的值,達成 select 雙向綁定


Select 綁定物件陣列

import { useState } from "react";

export default function App() {
  const age = [
    { label: "18歲以下", value: "0" },
    { label: "18歲~29歲", value: "1" },
    { label: "30歲~39歲", value: "2" },
    { label: "40歲~49歲", value: "3" },
    { label: "50歲~59歲", value: "4" },
    { label: "60歲以上", value: "5" }
  ];

  const [form, setForm] = useState({
    age: age[0].value
  });

  const changeAge = (e) => {
    setForm((state) => ({
      ...state,
      age: e.target.value
    }));
  };

  return (
    <form>
      <label>請選擇您的年齡區間</label>

      <select name="age" value={form.age} onChange={changeAge}>
        {age.map((item) => (
          <option key={item.value} value={item.value}>
            {item.label}
          </option>
        ))}
      </select>

      <h1>您選擇了: {age.find((item) => item.value === form.age).label}</h1>
    </form>
  );
}

有時候 select 的文字,跟要傳入的 value 是不一樣的,這時候可以用物件陣列,做法跟綁定字串陣列差不多,只要綁定物件裡的 value 就可以了

特別注意的是,要顯示選擇的項目,因為 form.age 綁定的是 value 值,我們想顯示 label 需要從 age 陣列去找 value 跟 form.age 相同的的物件,再取得物件的 label


radio 單選

radio 綁定物件

import { useState } from "react";

export default function App() {
  const [form, setForm] = useState({
    gender: "male",
  });

  const changeGender = (e) => {
    setForm((state) => ({
      ...state,
      gender: e.target.value
    }));
  };

  return (
    <form>
      <label>性別</label>

      <div>
        <input
          type="radio"
          id="male"
		  name="gender"
          value="male"
          onChange={changeGender}
          checked={form.gender === "male"}
        />
        <label htmlFor="male">男性</label>
      </div>

      <div>
        <input
          type="radio"
          id="female"
		  name="gender"
          value="female"
          onChange={changeGender}
          checked={form.gender === "female"}
        />
        <label htmlFor="female">女性</label>
      </div>
    </form>
  );
}

利用 valueonChange 達成雙向綁定,radio 還有 checked 屬性,依據 form.gender 去判斷是否 checked


radio 綁定陣列

import { useState } from "react";

export default function App() {
	const gender = [
    { label: "男性", value: "male" },
    { label: "女性", value: "female" }
  ];

  const [form, setForm] = useState({
    gender: "male",
  });

  const changeGender = (e) => {
    setForm((state) => ({
      ...state,
      gender: e.target.value
    }));
  };

  return (
    <form>
      <label>性別</label>

      {gender.map((item) => (
        <div key={item.value}>
          <input
            type="radio"
            id={item.value}
			name="gender"
            value={item.value}
            onChange={changeGender}
            checked={form.gender === item.value}
          />

          <label htmlFor={item.value}>
            {item.label}
          </label>
        </div>
      ))}
    </form>
  );
}

將項目整理成陣列,用 map 渲染列表,並綁定 valuechecked 的值


checkbox 多選

checkbox 綁定物件

import { useState } from "react";

export default function App() {
  const purpose = [
    { label: "約會聚餐", value: "date" },
    { label: "朋友聚會", value: "friend" },
    { label: "商務用餐", value: "business" },
    { label: "慶祝生日", value: "birthday" },
    { label: "其他", value: "others" }
  ];

  const [form, setForm] = useState({
    purpose: {
      date: false,
      friend: false,
      business: false,
      birthday: false,
      others: false
    }
  });

  const changePurpose = (e) => {
    const key = e.target.value;

    setForm((state) => ({
      ...state,
      purpose: {
        ...state.purpose,
        [key]: !state.purpose[key]
      }
    }));
  };

  return (
    <form>
      <label>此次用餐目的</label>

	  {purpose.map((item) => (
        <div key={item.value}>
          <input
            type="checkbox"
			name="purpose"
            value={item.value}
            id={item.value}
            checked={form.purpose[item.value]}
            onChange={changePurpose}
          />

          <label htmlFor={item.value}>
            {item.label}
          </label>
        </div>
      ))}
    </form>
  );
}

綁定物件的 boolean 值去控制是否 checked,並在 setForm 做開關的動作


checkbox 綁定陣列

import { useState } from "react";

export default function App() {
  const purpose = [
    { label: "約會聚餐", value: "date" },
    { label: "朋友聚會", value: "friend" },
    { label: "商務用餐", value: "business" },
    { label: "慶祝生日", value: "birthday" },
    { label: "其他", value: "others" }
  ];

  const [form, setForm] = useState({
    purpose: []
  });

  const changePurpose = (e) => {
    const value = e.target.value;

    setForm((state) => {
      if (state.purpose.includes(value)) {
        return {
          ...state,
          purpose: state.purpose.filter((item) => item !== value)
        };
      } else {
        return {
          ...state,
          purpose: [...state.purpose, value]
        };
      }
    });
  };

  return (
    <form>
	  <label>此次用餐目的</label>

      {purpose.map((item, idx) => (
        <div key={item.value}>
          <input
            type="checkbox"
            value={item.value}
			name="purpose"
            id={item.value}
            checked={form.purpose.includes(item.value)}
            onChange={changePurpose}
          />

          <label htmlFor={item.value}>
            {item.label}
          </label>
        </div>
      ))}
    </form>
  );
}

陣列會傳入有 checked 的 value,如點擊已 checked 的項目,則會用 filter 過濾掉此 value


file 檔案上傳與圖片預覽

import { useState } from "react";

export default function App() {
  const [form, setForm] = useState({
    file: ""
  });

  const changeFile = (e) => {
    // 取得第0筆檔案
    const file = e.target.files[0];
    // FileReader 讀取瀏覽器選中的檔案
    const fileReader = new FileReader();
    // 讀取完改變 img
    fileReader.addEventListener("load", fileLoad);
    // 將圖片繪出,轉換成 Base64 編碼
    fileReader.readAsDataURL(file);
  };

  const fileLoad = (e) => {
    // 此處的 e 為 fileReader
    setForm((state) => ({
      ...state,
      file: e.target.result
    }));
  };

  return (
    <form>
      <label>相關圖片</label>
      <div>
        <input
          type="file"
          id="upload"
		  name="file"
          onChange={changeFile}
        />
        <img src={form.file} width="100%" alt="" />
      </div>
    </form>
  );
}

type 為 file 時,沒辦法用 value 指定,透過 fileReader 讀取檔案,再轉換給 form.file


統一 function

因為 changeName、changeAge、changeGender 的 function 邏輯都是一樣的,所以可以在 onChange 時統一讀取同一個 function,如下讀取 changeValue,取得欄位的 name 屬性,並賦予新值

const changeValue = (e) => {
  const name = e.target.name;

  setForm((state) => ({
    ...state,
    [name]: e.target.value
  }));
};

完整 form 表單如下

import { useState } from "react";

export default function App() {
  const age = [
    { label: "18歲以下", value: "0" },
    { label: "18歲~29歲", value: "1" },
    { label: "30歲~39歲", value: "2" },
    { label: "40歲~49歲", value: "3" },
    { label: "50歲~59歲", value: "4" },
    { label: "60歲以上", value: "5" }
  ];

  const gender = [
    { label: "男性", value: "male" },
    { label: "女性", value: "female" }
  ];

  const purpose = [
    { label: "約會聚餐", value: "date" },
    { label: "朋友聚會", value: "friend" },
    { label: "商務用餐", value: "business" },
    { label: "慶祝生日", value: "birthday" },
    { label: "其他", value: "others" }
  ];

  const [form, setForm] = useState({
    name: "",
    number: "",
    gender: "male",
    age: age[0].value,
    purpose: [],
    file: ""
  });

  const changeNumber = (e) => {
    setForm((state) => ({
      ...state,
      number: parseInt(e.target.value, 10)
    }));
  };

  const changeValue = (e) => {
    const name = e.target.name;

    setForm((state) => ({
      ...state,
      [name]: e.target.value
    }));
  };

  const changePurpose = (e) => {
    const value = e.target.value;

    setForm((state) => {
      if (state.purpose.includes(value)) {
        return {
          ...state,
          purpose: state.purpose.filter((item) => item !== value)
        };
      } else {
        return {
          ...state,
          purpose: [...state.purpose, value]
        };
      }
    });
  };

  const changeFile = (e) => {
    // 取得第0筆檔案
    const file = e.target.files[0];
    // FileReader 讀取瀏覽器選中的檔案
    const fileReader = new FileReader();
    // 讀取完改變 img
    fileReader.addEventListener("load", fileLoad);
    // 將圖片繪出,轉換成 Base64 編碼
    fileReader.readAsDataURL(file);
  };

  const fileLoad = (e) => {
    // 此處的 e 為 fileReader
    setForm((state) => ({
      ...state,
      file: e.target.result
    }));
  };

  return (
    <div>
      <h1>React 熱炒店訂購單</h1>
      <form>
        <div>
          <label htmlFor="name">
            姓名
          </label>
          <input
            id="name"
            type="text"
            name="name"
            value={form.name}
            onChange={changeValue}
          />
        </div>

        <div>
          <label htmlFor="num">
            此次用餐人數
          </label>
          <input
            id="num"
            type="number"
            value={form.number}
            onChange={changeNumber}
          />
        </div>

        <div>
          <label>性別</label>
          <div>
            {gender.map((item) => (
              <div key={item.value}>
                <input
                  type="radio"
                  name="gender"
                  id={item.value}
                  value={item.value}
                  onChange={changeValue}
                  checked={form.gender === item.value}
                />
                <label htmlFor={item.value}>
                  {item.label}
                </label>
              </div>
            ))}
          </div>
        </div>

        <div>
          <label>請選擇您的年齡區間</label>

          <select
            name="age"
            value={form.age}
            onChange={changeValue}
          >
            {age.map((item) => (
              <option key={item.value} value={item.value}>
                {item.label}
              </option>
            ))}
          </select>

          <h6>
            您選擇了: {age.find((item) => item.value === form.age).label}
          </h6>
        </div>

        <div>
          <label>此次用餐目的</label>
          <div>
            {purpose.map((item, idx) => (
              <div key={item.value}>
                <input
                  type="checkbox"
                  value={item.value}
                  id={item.value}
                  checked={form.purpose.includes(item.value)}
                  onChange={changePurpose}
                />
                <label htmlFor={item.value}>
                  {item.label}
                </label>
              </div>
            ))}
          </div>
        </div>

        <div>
          <label>相關圖片</label>
          <div>
            <input
              type="file"
              id="upload"
              onChange={changeFile}
            />
            <button
              type="button"
              id="upload"
            >
              上傳
            </button>

            <img src={form.file} width="100%" />
          </div>
        </div>
      </form>
    </div>
  );
}

打開 codesandbox 程式碼範例 一起試看看吧!/images/emoticon/emoticon08.gif


[任務解題]

依照上面的範例,加上了 className,已完成訂購單囉!你真是幫了餐廳一個大忙!
form

結語

表單的處理在 React 也是一門學問,React 不像其他框架有做雙向綁定的模版,所以利用 onChange 可以幫助我們綁定新的值,就達到雙向綁定的效果囉! /images/emoticon/emoticon61.gif


本文將同步更新至我的部落格
Lala 的前端大補帖



上一篇
[DAY 14] 塞入你的 HTML-dangerouslySetInnerHTML
下一篇
[DAY 16] 好用的 React Developer Tools 偵錯工具
系列文
你 React 了嗎? 30 天解鎖 React 技能30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言